원본 항목: Part 7. API 오용 — 4. 오용: 문자열 관리CWE-251repo 폴더: sd57_mbstr/ (빌드·실행은 §3-1)
목표 시간: 1회차 12분 / 2회차 7분 / 3회차 5분
가이드 원문의 _mbscpy/_mbscpy_s는 MSVC 전용 함수라 이 환경(gcc/glibc)에는 아예 존재하지 않는다.
이 드릴은 같은 교훈(“크기 인자 없는 복사 금지”)을 strcpy로 재현하면서, 거기서 한 걸음 더 나아가 — 크기를 제한해도 멀티바이트(UTF-8) 문자 중간을 잘라버리면 여전히 결함이라는 걸 실제 인코딩으로 확인한다.
이 드릴의 문서 구성 (SD01~SD58 공통)
① 요구사항서 — 어떤 코드가 뚫려 있고, 무엇을 만드나.
② pseudo code 템플릿 — Bad 와 Good 의 뼈대이고, 회차마다 가린다.
③ 테스트 코드 — 공격은 막히고 정상은 통과해야 끝이다.
④ 자주 하는 실수
⑤ 모범답안 — 맨 뒤에 접어 둔다.
변형 A/B/C 는 같은 치환 규칙을 다른 소재로 다시 치는 것이다.
0. 이 드릴로 체화할 것
- strcpy(dst, src); /* 크기 인자 자체가 없음 -- _mbscpy와 동일한 결함 */+ size_t copy_len = /* dst_size-1과 strlen(src) 중 작은 값 */;+ while (copy_len > 0 && ((unsigned char)src[copy_len] & 0xC0) == 0x80) {+ copy_len--; /* 자르는 지점이 멀티바이트 문자 중간이면 물러남 */+ }+ memcpy(dst, src, copy_len);+ dst[copy_len] = '\0';
멀티바이트 문자열은 “글자 수 ≠ 바이트 수”다.
크기만 맞춘 단순 절단은 문자 하나를 반으로 쪼개 깨진 바이트 시퀀스를 남길 수 있다.
회차별 진행
회차
변형
보는 것
순서
1회차
A 사용자 이름 복사
① 요구사항서 ② pseudo code 전체
1. Bad 를 그대로 쳐서 취약점을 관찰한다. 2. Good pseudo 를 한 줄씩 코드로 바꾼다. 3. ③ 테스트를 실행한다.
2회차
B 문자열 연결(_mbscat 상당)로 변형
① 요구사항서 ② pseudo code 의 번호와 제목만 (Level 2)
1. Bad 를 치고 취약점을 확인한다. 2. 번호만 보고 Good 을 쓴다. 3. ③ 테스트를 실행한다.
3회차
C 3바이트·4바이트 UTF-8 문자로 확장
① 요구사항서만
1. ③ 테스트를 먼저 읽는다. 2. 백지에서 Bad 부터 Good 까지 재현한다.
1회차에 막히면 같은 변형을 한 번 더 친다.
3회차를 통과하면 A 를 백지로 다시 친다.
int mb_copy_good(char *dst, size_t dst_size, const char *src){ /* 아무것도 안 본다. * §1 요구사항서와 §0 의 치환 규칙만 보고 친다. * * 막히면 아래만 찾아본다. * - UTF-8 연속 바이트는 `10xxxxxx` (`& 0xC0 == 0x80`) * - 자른 뒤 고치는 게 아니라 **안전한 지점을 먼저 찾고** 자른다 */}
막혔을 때 찾는 순서
컴파일 에러가 나면 에러 문구를 본다.
unknown type name / implicit declaration 이면 include 누락이다.
man 3 <함수> 를 연다.
SYNOPSIS 맨 위의 #include 를 확인한다.
로직이 막히면 §3 테스트의 기대값을 먼저 읽는다.
공격 입력표(§1)와 테스트가 답이다.
#define _DEFAULT_SOURCE#include <stdio.h>#include <string.h>#include <sys/wait.h>#include <unistd.h>#include "mbstr.h"static int g_fail = 0;#define T_TRUE(cond, msg) \ do { if (!(cond)) { g_fail++; \ printf(" X %s:%d %s\n", __func__, __LINE__, msg); } } while (0)static int expect_crash(void (*fn)(void)){ pid_t pid = fork(); if (pid == 0) { fn(); _exit(0); } int status = 0; waitpid(pid, &status, 0); if (WIFSIGNALED(status)) return 1; if (WIFEXITED(status) && WEXITSTATUS(status) != 0) return 1; return 0;}static void call_bad(void){ char dst[8]; mb_copy_bad(dst, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");}static void test_bad_is_vulnerable(void){ T_TRUE(expect_crash(call_bad) == 1, "Bad는 크기 인자가 없어 대상 버퍼보다 긴 입력에서 크래시해야 한다(취약점 재현)");}static void test_good_respects_utf8_boundary(void){ char dst[5]; /* "caf" + '\0' 까지만 들어가는 크기 -- é(0xC3 0xA9)는 중간에 걸림 */ T_TRUE(mb_copy_good(dst, sizeof(dst), "caf\xC3\xA9") == 0, "Good은 항상 성공해야 한다(잘라서라도 완료)"); T_TRUE(strcmp(dst, "caf") == 0, "Good은 멀티바이트 문자 중간에서 자르지 않고 문자 경계까지 물러나야 한다(잘린 é 바이트가 남으면 안 됨)");}static void test_good_normal(void){ char dst[16]; T_TRUE(mb_copy_good(dst, sizeof(dst), "hi") == 0, "정상 크기 입력은 성공해야 한다"); T_TRUE(strcmp(dst, "hi") == 0, "짧은 입력은 그대로 복사돼야 한다");}int main(void){ test_bad_is_vulnerable(); test_good_respects_utf8_boundary(); test_good_normal(); printf(g_fail ? "FAIL %d\n" : "PASS\n", g_fail); return g_fail ? 1 : 0;}
UTF-8 연속 바이트를 어떻게 알아보는가
UTF-8은 첫 바이트의 상위 비트 패턴으로 문자의 전체 길이를 알 수 있게 설계됐다.
연속 바이트(문자의 두 번째 바이트 이후)는 항상 10xxxxxx 형태(& 0xC0 == 0x80)다.
mb_copy_good은 자르는 지점의 바이트가 이 패턴이면 “문자 중간”이라고 판단해 한 바이트씩 물러난다 — 첫 바이트(0xC3처럼 & 0xC0 == 0xC0)에 도달하면 그게 문자의 시작이므로 멈춘다.
눈여겨볼 점: mb_copy_good의 절단 로직은 자른 뒤 점검하는 게 아니라, 자르는 지점 자체를 미리 검사한다(while 루프가 memcpy 이전에 끝난다).
“일단 자르고 나중에 고치는” 순서가 아니라 “안전한 지점을 먼저 찾고 그 다음에 자르는” 순서라는 걸 기억한다.